#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright 2014 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

"""Helper functions useful when writing scripts that integrate with GN.

The main functions are ToGNString and from_gn_string which convert between
serialized GN variables and Python variables.

To use in a random python file in the build:

  import os
  import sys

  sys.path.append(os.path.join(os.path.dirname(__file__),
                               os.pardir, os.pardir, "build"))
  import gn_helpers

Where the sequence of parameters to join is the relative path from your source
file to the build directory.
"""


class GNException(Exception):
    pass


def to_gn_string(value: str, allow_dicts: bool = True) -> str:
    """Returns a stringified GN equivalent of the Python value.

    allow_dicts indicates if this function will allow converting dictionaries
    to GN scopes. This is only possible at the top level, you can't nest a
    GN scope in a list, so this should be set to False for recursive calls.
    """
    if isinstance(value, str):
        if value.find('\n') >= 0:
            raise GNException("Trying to print a string with a newline in it.")
        return '"' + \
               value.replace('\\', '\\\\').replace('"', '\\"').replace('$', '\\$') + \
               '"'

    if isinstance(value, str):
        return to_gn_string(value.encode('utf-8'))

    if isinstance(value, bool):
        if value:
            return "true"
        return "false"

    if isinstance(value, list):
        return '[ %s ]' % ', '.join(to_gn_string(v) for v in value)

    if isinstance(value, dict):
        if not allow_dicts:
            raise GNException("Attempting to recursively print a dictionary.")
        result = ""
        for key in sorted(value):
            if not isinstance(key, str):
                raise GNException("Dictionary key is not a string.")
            result += "%s = %s\n" % (key, to_gn_string(value[key], False))
        return result

    if isinstance(value, int):
        return str(value)

    raise GNException("Unsupported type when printing to GN.")


def from_gn_string(input_string: str) -> dict:
    """Converts the input string from a GN serialized value to Python values.

    For details on supported types see GNValueParser.parse() below.

    If your GN script did:
      something = [ "file1", "file2" ]
      args = [ "--values=$something" ]
    The command line would look something like:
      --values="[ \"file1\", \"file2\" ]"
    Which when interpreted as a command line gives the value:
      [ "file1", "file2" ]

    You can parse this into a Python list using GN rules with:
      input_values = FromGNValues(options.values)
    Although the Python 'ast' module will parse many forms of such input, it
    will not handle GN escaping properly, nor GN booleans. You should use this
    function instead.


    A NOTE ON STRING HANDLING:

    If you just pass a string on the command line to your Python script, or use
    string interpolation on a string variable, the strings will not be quoted:
      str = "asdf"
      args = [ str, "--value=$str" ]
    Will yield the command line:
      asdf --value=asdf
    The unquoted asdf string will not be valid input to this function, which
    accepts only quoted strings like GN scripts. In such cases, you can just
    use the Python string literal directly.

    The main use cases for this is for other types, in particular lists. When
    using string interpolation on a list (as in the top example) the embedded
    strings will be quoted and escaped according to GN rules so the list can be
    re-parsed to get the same result.
    """
    parser = GNValueParser(input_string)
    return parser.parse()


def from_gn_args(input_string: str) -> dict:
    """Converts a string with a bunch of gn arg assignments into a Python dict.

    Given a whitespace-separated list of

      <ident> = (integer | string | boolean | <list of the former>)

    gn assignments, this returns a Python dict, i.e.:

      from_gn_args("foo=true\nbar=1\n") -> { 'foo': True, 'bar': 1 }.

    Only simple types and lists supported; variables, structs, calls
    and other, more complicated things are not.

    This routine is meant to handle only the simple sorts of values that
    arise in parsing --args.
    """
    parser = GNValueParser(input_string)
    return parser.parse_args()


def unescape_gn_special_char(char_after_backslash: str) -> str:
    # Process the GN escape character and return it if it is a valid escape character; Otherwise, return a back slash
    if char_after_backslash in ('$', '"', '\\'):
        return char_after_backslash
    else:
        return '\\'


def unescape_gn_string(value: list) -> str:
    """Given a string with GN escaping, returns the unescaped string.

    Be careful not to feed with input from a Python parsing function like
    'ast' because it will do Python unescaping, which will be incorrect when
    fed into the GN unescaper.
    """
    result = []
    i = 0
    skip_char = False
    while i < len(value):
        if value[i] == '\\':
            if i < len(value) - 1:
            # If it is not the last element of the list and the current character is a back slash
                next_char = value[i + 1] 
                result.append(unescape_gn_special_char(next_char))
                skip_char = next_char in ('$', '"', '\\')
        else:
            result.append(value[i])
        i += 2 if skip_char else 1
        skip_char = False
    return ''.join(result)


def _is_digit_or_minus(char: str):
    return char in "-0123456789"


class GNValueParser(object):
    """Duplicates GN parsing of values and converts to Python types.

    Normally you would use the wrapper function FromGNValue() below.

    If you expect input as a specific type, you can also call one of the Parse*
    functions directly. All functions throw GNException on invalid input.
    """

    def __init__(self, string: str):
        self.input = string
        self.cur = 0

    def is_done(self) -> bool:
        return self.cur == len(self.input)

    def consume_whitespace(self):
        while not self.is_done() and self.input[self.cur] in ' \t\n':
            self.cur += 1

    def parse(self):
        """Converts a string representing a printed GN value to the Python type.

        See additional usage notes on from_gn_string above.

        - GN booleans ('true', 'false') will be converted to Python booleans.

        - GN numbers ('123') will be converted to Python numbers.

        - GN strings (double-quoted as in '"asdf"') will be converted to Python
          strings with GN escaping rules. GN string interpolation (embedded
          variables preceded by $) are not supported and will be returned as
          literals.

        - GN lists ('[1, "asdf", 3]') will be converted to Python lists.

        - GN scopes ('{ ... }') are not supported.
        """
        result = self._parse_allow_trailing()
        self.consume_whitespace()
        if not self.is_done():
            raise GNException("Trailing input after parsing:\n  " +
                              self.input[self.cur:])
        return result

    def parse_args(self) -> dict:
        """Converts a whitespace-separated list of ident=literals to a dict.

        See additional usage notes on from_gn_args, above.
        """
        d = {}

        self.consume_whitespace()
        while not self.is_done():
            ident = self._parse_ident()
            self.consume_whitespace()
            if self.input[self.cur] != '=':
                raise GNException("Unexpected token: " + self.input[self.cur:])
            self.cur += 1
            self.consume_whitespace()
            val = self._parse_allow_trailing()
            self.consume_whitespace()
            d[ident] = val

        return d

    def parse_number(self) -> int:
        self.consume_whitespace()
        if self.is_done():
            raise GNException('Expected number but got nothing.')

        begin = self.cur

        # The first character can include a negative sign.
        if not self.is_done() and _is_digit_or_minus(self.input[self.cur]):
            self.cur += 1
        while not self.is_done() and self.input[self.cur].isdigit():
            self.cur += 1

        number_string = self.input[begin:self.cur]
        if not len(number_string) or number_string == '-':
            raise GNException("Not a valid number.")
        return int(number_string)

    def parse_string(self) -> str:
        self.consume_whitespace()
        if self.is_done():
            raise GNException('Expected string but got nothing.')

        if self.input[self.cur] != '"':
            raise GNException('Expected string beginning in a " but got:\n  ' +
                              self.input[self.cur:])
        self.cur += 1  # Skip over quote.

        begin = self.cur
        while not self.is_done() and self.input[self.cur] != '"':
            if self.input[self.cur] == '\\':
                self.cur += 1  # Skip over the backslash.
                if self.is_done():
                    raise GNException("String ends in a backslash in:\n  " +
                                      self.input)
            self.cur += 1

        if self.is_done():
            raise GNException('Unterminated string:\n  ' + self.input[begin:])

        end = self.cur
        self.cur += 1  # Consume trailing ".

        return unescape_gn_string(self.input[begin:end])

    def parse_list(self):
        self.consume_whitespace()
        if self.is_done():
            raise GNException('Expected list but got nothing.')

        # Skip over opening '['.
        if self.input[self.cur] != '[':
            raise GNException("Expected [ for list but got:\n  " +
                              self.input[self.cur:])
        self.cur += 1
        self.consume_whitespace()
        if self.is_done():
            raise GNException("Unterminated list:\n  " + self.input)

        list_result = []
        previous_had_trailing_comma = True
        while not self.is_done():
            if self.input[self.cur] == ']':
                self.cur += 1  # Skip over ']'.
                return list_result

            if not previous_had_trailing_comma:
                raise GNException("List items not separated by comma.")

            list_result += [self._parse_allow_trailing()]
            self.consume_whitespace()
            if self.is_done():
                break

            # Consume comma if there is one.
            previous_had_trailing_comma = self.input[self.cur] == ','
            if previous_had_trailing_comma:
                # Consume comma.
                self.cur += 1
                self.consume_whitespace()

        raise GNException("Unterminated list:\n  " + self.input)

    def _constant_follows(self, constant) -> bool:
        """Returns true if the given constant follows immediately at the
        current location in the input. If it does, the text is consumed and
        the function returns true. Otherwise, returns false and the current
        position is unchanged."""
        end = self.cur + len(constant)
        if end > len(self.input):
            return False  # Not enough room.
        if self.input[self.cur:end] == constant:
            self.cur = end
            return True
        return False
    
    def _parse_allow_trailing(self):
        """Internal version of Parse that doesn't check for trailing stuff."""
        self.consume_whitespace()
        if self.is_done():
            raise GNException("Expected input to parse.")

        next_char = self.input[self.cur]
        if next_char == '[':
            return self.parse_list()
        elif _is_digit_or_minus(next_char):
            return self.parse_number()
        elif next_char == '"':
            return self.parse_string()
        elif self._constant_follows('true'):
            return True
        elif self._constant_follows('false'):
            return False
        else:
            raise GNException("Unexpected token: " + self.input[self.cur:])

    def _parse_ident(self) -> str:
        ident = ''

        next_char = self.input[self.cur]
        if not next_char.isalpha() and not next_char == '_':
            raise GNException("Expected an identifier: " + self.input[self.cur:])

        ident += next_char
        self.cur += 1

        next_char = self.input[self.cur]
        while next_char.isalpha() or next_char.isdigit() or next_char == '_':
            ident += next_char
            self.cur += 1
            next_char = self.input[self.cur]

        return ident
